[id].vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. <template>
  2. <div class="admin--page-content">
  3. <div v-if="isLoading" class="admin--loading">데이터를 불러오는 중...</div>
  4. <div v-else class="admin--form">
  5. <form @submit.prevent="handleSubmit">
  6. <table class="admin--form--table">
  7. <colgroup>
  8. <col style="width: 140px;">
  9. <col>
  10. </colgroup>
  11. <tbody>
  12. <tr>
  13. <th><div>아이디</div></th>
  14. <td>
  15. <div class="input--wrap">
  16. <span class="admin--table-title">{{ formData.username }}</span>
  17. </div>
  18. </td>
  19. </tr>
  20. <tr>
  21. <th><div>이름 <span class="admin--required">*</span></div></th>
  22. <td>
  23. <div class="input--wrap">
  24. <input v-model="formData.name" type="text" class="admin--form-input w--280" maxlength="30" />
  25. </div>
  26. </td>
  27. </tr>
  28. <tr>
  29. <th><div>핸드폰 <span class="admin--required">*</span></div></th>
  30. <td>
  31. <div class="input--wrap">
  32. <select v-model="phone1" class="admin--form-select w--120">
  33. <option value="010">010</option>
  34. <option value="011">011</option>
  35. <option value="016">016</option>
  36. <option value="017">017</option>
  37. <option value="018">018</option>
  38. <option value="019">019</option>
  39. </select>
  40. <span class="mx--8">-</span>
  41. <input
  42. v-model="phone2"
  43. type="text"
  44. inputmode="numeric"
  45. class="admin--form-input w--120"
  46. maxlength="4"
  47. @input="onlyDigits('phone2')"
  48. />
  49. <span class="mx--8">-</span>
  50. <input
  51. v-model="phone3"
  52. type="text"
  53. inputmode="numeric"
  54. class="admin--form-input w--120"
  55. maxlength="4"
  56. @input="onlyDigits('phone3')"
  57. />
  58. </div>
  59. </td>
  60. </tr>
  61. <tr>
  62. <th><div>이메일 <span class="admin--required">*</span></div></th>
  63. <td>
  64. <div class="input--wrap">
  65. <input
  66. v-model="emailLocal"
  67. type="text"
  68. class="admin--form-input w--160"
  69. maxlength="50"
  70. autocomplete="off"
  71. />
  72. <span class="mx--8">@</span>
  73. <input
  74. v-model="emailDomain"
  75. type="text"
  76. class="admin--form-input w--160"
  77. placeholder="domain.com"
  78. maxlength="50"
  79. :readonly="emailDomainSelect !== 'custom'"
  80. autocomplete="off"
  81. />
  82. <select v-model="emailDomainSelect" @change="onDomainChange" class="admin--form-select w--160 ml--8">
  83. <option value="">선택</option>
  84. <option value="naver.com">naver.com</option>
  85. <option value="gmail.com">gmail.com</option>
  86. <option value="daum.net">daum.net</option>
  87. <option value="hanmail.net">hanmail.net</option>
  88. <option value="kakao.com">kakao.com</option>
  89. <option value="nate.com">nate.com</option>
  90. <option value="custom">직접입력</option>
  91. </select>
  92. </div>
  93. </td>
  94. </tr>
  95. <tr>
  96. <th><div>권한 <span class="admin--required">*</span></div></th>
  97. <td>
  98. <div class="input--wrap">
  99. <label class="admin--radio-label" :class="{ 'is-disabled': !isSuperAdmin }">
  100. <input type="radio" v-model="formData.role" value="admin" :disabled="!isSuperAdmin" />
  101. 관리자
  102. </label>
  103. <label class="admin--radio-label ml--16" :class="{ 'is-disabled': !isSuperAdmin }">
  104. <input type="radio" v-model="formData.role" value="super_admin" :disabled="!isSuperAdmin" />
  105. 슈퍼 관리자
  106. </label>
  107. </div>
  108. <p v-if="!isSuperAdmin" class="mt--10 txt--muted">권한 변경은 슈퍼 관리자만 할 수 있습니다.</p>
  109. <p v-else-if="formData.role === 'super_admin'" class="mt--10">슈퍼 관리자는 모든 메뉴에 접근할 수 있습니다.</p>
  110. </td>
  111. </tr>
  112. <tr v-if="formData.role === 'admin'">
  113. <th><div>메뉴 권한 <span class="admin--required">*</span></div></th>
  114. <td>
  115. <div class="admin--permissions-grid" :class="{ 'is-disabled': !isSuperAdmin }">
  116. <label v-for="opt in menuOptions" :key="opt.id" class="admin--checkbox-label" :class="{ 'is-disabled': !isSuperAdmin }">
  117. <input type="checkbox" :value="opt.id" v-model="formData.permissions" :disabled="!isSuperAdmin" />
  118. {{ opt.title }}
  119. </label>
  120. </div>
  121. <p v-if="!isSuperAdmin" class="mt--10 txt--muted">메뉴 권한 변경은 슈퍼 관리자만 할 수 있습니다.</p>
  122. <p v-else class="mt--10">관리자가 접근할 수 있는 메뉴를 선택하세요. 대시보드는 모든 관리자에게 기본 제공됩니다.</p>
  123. </td>
  124. </tr>
  125. <tr v-if="!isMyAccount">
  126. <th><div>상태 <span class="admin--required">*</span></div></th>
  127. <td>
  128. <div class="input--wrap">
  129. <label class="admin--radio-label">
  130. <input type="radio" v-model="formData.status" value="active" /> 활성
  131. </label>
  132. <label class="admin--radio-label ml--16">
  133. <input type="radio" v-model="formData.status" value="inactive" /> 휴면
  134. </label>
  135. <label class="admin--radio-label ml--16">
  136. <input type="radio" v-model="formData.status" value="suspended" /> 정지
  137. </label>
  138. </div>
  139. </td>
  140. </tr>
  141. </tbody>
  142. </table>
  143. <!-- 버튼 영역 -->
  144. <div class="admin--form-actions">
  145. <button type="button" class="admin--btn" @click="goToDetail">
  146. ← 취소
  147. </button>
  148. <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving">
  149. {{ isSaving ? "저장 중..." : "저장" }}
  150. </button>
  151. </div>
  152. </form>
  153. </div>
  154. <!-- 알림 모달 -->
  155. <AdminAlertModal
  156. v-if="alertModal.show"
  157. :title="alertModal.title"
  158. :message="alertModal.message"
  159. :type="alertModal.type"
  160. @confirm="handleAlertConfirm"
  161. @cancel="handleAlertCancel"
  162. @close="closeAlertModal"
  163. />
  164. </div>
  165. </template>
  166. <script setup>
  167. import { ref, computed, onMounted } from "vue";
  168. import { useRoute, useRouter } from "vue-router";
  169. import AdminAlertModal from "~/components/admin/AdminAlertModal.vue";
  170. definePageMeta({
  171. layout: "admin",
  172. middleware: ["auth"],
  173. });
  174. const route = useRoute();
  175. const router = useRouter();
  176. const { get, put } = useApi();
  177. const { user, isSuperAdmin } = useAuth();
  178. const adminId = route.params.id;
  179. const isMyAccount = computed(() => Number(user.value?.id) === Number(adminId));
  180. const isLoading = ref(true);
  181. const isSaving = ref(false);
  182. const formData = ref({
  183. username: "",
  184. name: "",
  185. role: "admin",
  186. status: "active",
  187. permissions: [],
  188. });
  189. // 메뉴 권한 옵션 (admin.vue menuItems와 동일)
  190. const menuOptions = [
  191. { id: "admin", title: "관리자 관리" },
  192. { id: "field", title: "분야 및 지역 관리" },
  193. { id: "fishing", title: "선상 및 낚시터 관리" },
  194. { id: "challenge", title: "챌린지 관리" },
  195. { id: "quest", title: "퀘스트 관리" },
  196. { id: "item", title: "아이템 관리" },
  197. { id: "species", title: "어종 관리" },
  198. { id: "user", title: "회원 관리" },
  199. ];
  200. // 이메일 분할
  201. const KNOWN_DOMAINS = ["naver.com", "gmail.com", "daum.net", "hanmail.net", "kakao.com", "nate.com"];
  202. const emailLocal = ref("");
  203. const emailDomain = ref("");
  204. const emailDomainSelect = ref("");
  205. const onDomainChange = () => {
  206. if (emailDomainSelect.value === "custom") {
  207. emailDomain.value = "";
  208. } else if (emailDomainSelect.value !== "") {
  209. emailDomain.value = emailDomainSelect.value;
  210. }
  211. };
  212. // 핸드폰 분할
  213. const phone1 = ref("010");
  214. const phone2 = ref("");
  215. const phone3 = ref("");
  216. const onlyDigits = (key) => {
  217. if (key === "phone2") phone2.value = phone2.value.replace(/\D/g, "");
  218. else if (key === "phone3") phone3.value = phone3.value.replace(/\D/g, "");
  219. };
  220. // 알림 모달
  221. const alertModal = ref({ show: false, title: "알림", message: "", type: "alert", onConfirm: null });
  222. const showAlert = (message, title = "알림") => {
  223. alertModal.value = { show: true, title, message, type: "alert", onConfirm: null };
  224. };
  225. const closeAlertModal = () => { alertModal.value.show = false; };
  226. const handleAlertConfirm = () => {
  227. if (alertModal.value.onConfirm) alertModal.value.onConfirm();
  228. closeAlertModal();
  229. };
  230. const handleAlertCancel = () => closeAlertModal();
  231. // 상세 조회 + 초기화
  232. const loadDetail = async () => {
  233. isLoading.value = true;
  234. const { data: res, error } = await get(`/admin/${adminId}`);
  235. if (error || !res?.success) {
  236. showAlert(error?.message || res?.message || "조회에 실패했습니다.", "오류");
  237. isLoading.value = false;
  238. return;
  239. }
  240. const row = res.data || {};
  241. // 일반 admin이 슈퍼관리자 수정 진입 시 차단
  242. if (row.role === "super_admin" && !isSuperAdmin.value) {
  243. isLoading.value = false;
  244. alertModal.value = {
  245. show: true,
  246. title: "접근 불가",
  247. message: "슈퍼 관리자 계정은 슈퍼 관리자만 수정할 수 있습니다.",
  248. type: "alert",
  249. onConfirm: () => router.push(`/site-manager/admin/detail/${adminId}`),
  250. };
  251. setTimeout(() => router.push(`/site-manager/admin/detail/${adminId}`), 1200);
  252. return;
  253. }
  254. formData.value = {
  255. username: row.username ?? "",
  256. name: row.name ?? "",
  257. role: row.role ?? "admin",
  258. status: row.status ?? "active",
  259. // super_admin은 "all" 문자열이 올 수 있어 배열로 통일
  260. permissions: Array.isArray(row.permissions) ? [...row.permissions] : [],
  261. };
  262. // 이메일 분할
  263. const email = row.email ?? "";
  264. const at = email.lastIndexOf("@");
  265. if (at > 0) {
  266. emailLocal.value = email.slice(0, at);
  267. emailDomain.value = email.slice(at + 1);
  268. emailDomainSelect.value = KNOWN_DOMAINS.includes(emailDomain.value) ? emailDomain.value : "custom";
  269. } else {
  270. emailLocal.value = "";
  271. emailDomain.value = "";
  272. emailDomainSelect.value = "";
  273. }
  274. // 핸드폰 분할 — "010-1234-5678" / "01012345678" / "010 1234 5678" 모두 처리
  275. const phoneRaw = String(row.phone ?? "").replace(/\D/g, "");
  276. if (phoneRaw.length >= 9) {
  277. if (phoneRaw.length === 11) {
  278. phone1.value = phoneRaw.slice(0, 3);
  279. phone2.value = phoneRaw.slice(3, 7);
  280. phone3.value = phoneRaw.slice(7, 11);
  281. } else if (phoneRaw.length === 10) {
  282. phone1.value = phoneRaw.slice(0, 3);
  283. phone2.value = phoneRaw.slice(3, 6);
  284. phone3.value = phoneRaw.slice(6, 10);
  285. }
  286. } else {
  287. phone1.value = "010";
  288. phone2.value = "";
  289. phone3.value = "";
  290. }
  291. isLoading.value = false;
  292. };
  293. // 폼 검증
  294. const validate = () => {
  295. const f = formData.value;
  296. const name = f.name.trim();
  297. if (!name) return "이름을 입력하세요.";
  298. if (name.length > 30) return "이름은 30자 이내";
  299. const local = emailLocal.value.trim();
  300. const domain = emailDomain.value.trim();
  301. if (!local) return "이메일 아이디를 입력하세요.";
  302. if (!domain) return "이메일 도메인을 입력하거나 선택하세요.";
  303. const email = `${local}@${domain}`;
  304. if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "이메일 형식이 올바르지 않습니다.";
  305. if (!phone1.value) return "핸드폰 앞자리를 선택하세요.";
  306. if (!/^\d{3,4}$/.test(phone2.value)) return "핸드폰 가운데 자리(3~4자리 숫자)를 입력하세요.";
  307. if (!/^\d{4}$/.test(phone3.value)) return "핸드폰 끝자리(4자리 숫자)를 입력하세요.";
  308. if (!["super_admin", "admin"].includes(f.role)) return "권한을 선택하세요.";
  309. if (!["active", "inactive", "suspended"].includes(f.status)) return "상태를 선택하세요.";
  310. if (f.role === "admin" && f.permissions.length === 0) {
  311. return "관리자에게 부여할 메뉴 권한을 1개 이상 선택하세요.";
  312. }
  313. return null;
  314. };
  315. // 폼 제출
  316. const handleSubmit = async () => {
  317. const err = validate();
  318. if (err) {
  319. showAlert(err, "입력 오류");
  320. return;
  321. }
  322. isSaving.value = true;
  323. const f = formData.value;
  324. const payload = {
  325. name: f.name.trim(),
  326. email: `${emailLocal.value.trim()}@${emailDomain.value.trim()}`,
  327. phone: `${phone1.value}-${phone2.value}-${phone3.value}`,
  328. status: f.status,
  329. };
  330. // role/permissions는 슈퍼관리자만 전송 (백엔드 가드와 매칭)
  331. if (isSuperAdmin.value) {
  332. payload.role = f.role;
  333. payload.permissions = f.role === "admin" ? f.permissions : [];
  334. }
  335. const { data, error } = await put(`/admin/${adminId}`, payload);
  336. isSaving.value = false;
  337. if (error || !data?.success) {
  338. showAlert(error?.message || data?.message || "수정에 실패했습니다.", "오류");
  339. return;
  340. }
  341. showAlert(data.message || "수정되었습니다.", "성공");
  342. setTimeout(() => router.push(`/site-manager/admin/detail/${adminId}`), 800);
  343. };
  344. const goToDetail = () => router.push(`/site-manager/admin/detail/${adminId}`);
  345. onMounted(() => {
  346. loadDetail();
  347. });
  348. </script>